<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ClickHouse Dashboard</title>
    <link rel="icon" href="">
    <script src="https://cdn.jsdelivr.net/npm/uplot@1.6.21/dist/uPlot.iife.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
    <style>
        :root {
            --color: black;
            --background-color-1: #00CCFF;
            --background: linear-gradient(to bottom, var(--background-color-1), #00D0D0);
            --chart-background: white;
            --shadow-color: rgba(0, 0, 0, 0.25);
            --moving-shadow-color: rgba(0, 0, 0, 0.5);
            --input-shadow-color: rgba(0, 255, 0, 1);
            --error-color: red;
            --global-error-color: white;
            --legend-background: rgba(255, 255, 0, 0.75);
            --title-color: #666;
            --text-color: black;
            --edit-title-background: #FEE;
            --edit-title-border: #F88;
            --button-background-color: #FFCB80;
            --button-text-color: black;
            --new-chart-background-color: #EEE;
            --new-chart-text-color: black;
            --param-background-color: #EEE;
            --param-text-color: black;
            --input-background: white;
            --chart-button-hover-color: red;
        }

        [data-theme="dark"] {
            --color: white;
            --background-color-1: #151C2C;
            --background: var(--background-color-1);
            --chart-background: #1b2834;
            --shadow-color: rgba(0, 0, 0, 0);
            --moving-shadow-color: rgba(255, 255, 255, 0.25);
            --input-shadow-color: rgba(255, 128, 0, 0.25);
            --error-color: #F66;
            --legend-background: rgba(0, 96, 128, 0.75);
            --title-color: white;
            --text-color: white;
            --edit-title-background: #364f69;
            --edit-title-border: #333;
            --button-background-color: orange;
            --button-text-color: black;
            --new-chart-background-color: #666;
            --new-chart-text-color: white;
            --param-background-color: #666;
            --param-text-color: white;
            --input-background: #364f69;
            --chart-button-hover-color: #F40;
        }

        * {
            box-sizing: border-box;
        }
        html, body {
            color: var(--color);
            height: 100%;
            overflow: auto;
            margin: 0;
        }
        body {
            font-family: Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
            padding: 1rem;
            overflow-x: hidden;
            background: var(--background);
            display: grid;
            grid-template-columns: auto;
            grid-template-rows: fit-content(10%) auto;
        }
        input {
            /* iPad, Safari */
            border-radius: 0;
            margin: 0;
        }
        #charts
        {
            height: 100%;
            display: flex;
            flex-flow: row wrap;
            gap: 1rem;
        }
        .chart {
            flex: 1 1 40rem;
            min-height: 16rem;
            background: var(--chart-background);
            box-shadow: 1px 1px 0 var(--shadow-color);
            overflow: hidden;
            position: relative;
        }

        .chart-maximized {
            flex: 1 100%;
            height: 75vh
        }

        .chart-moving {
            z-index: 11;
            box-shadow: 0 0 2rem var(--moving-shadow-color);
        }

        .chart-displaced {
            opacity: 75%;
            filter: blur(1px);
        }

        .chart > div { position: absolute; }

        .inputs {
            height: auto;
            width: 100%;

            font-size: 14pt;

            display: flex;
            flex-flow: column nowrap;
            justify-content: center;

            position: sticky;
            top: -1rem;
            margin-top: -1rem;
            margin-left: -1rem;
            margin-right: -1rem;
            border-top: 1rem solid var(--background-color-1);
            border-left: 1rem solid var(--background-color-1);
            border-right: 1rem solid var(--background-color-1);
            box-sizing: content-box;
            z-index: 1000;
            background: var(--background-color-1);
        }

        .inputs.unconnected {
            height: 100vh;
        }
        .unconnected #params {
            display: flex;
            flex-flow: column nowrap;
            justify-content: center;
            align-items: center;
        }
        .unconnected #connection-params {
            width: 50%;

            display: flex;
            flex-flow: column nowrap;
        }
        .unconnected #url {
            width: 100%;
        }
        .unconnected #button-options {
            display: grid;
            grid-auto-flow: column;
            grid-auto-columns: 1fr;
            gap: 0.3rem;
        }
        .unconnected #user {
            margin-right: 0;
            width: auto;
        }
        .unconnected #password {
            width: auto;
        }
        #user {
            margin-right: 0.25rem;
            width: 50%;
        }
        #password {
            width: 49.5%;
        }
        .unconnected input {
            margin-bottom: 5px;
        }

        #username-password {
            width: 100%;

            display: flex;
            flex-flow: row nowrap;
        }
        .unconnected #username-password {
            width: 100%;

            gap: 0.3rem;

            display: grid;
            grid-template-columns: 1fr 1fr;
        }

        .inputs #chart-params {
            display: block;
        }

        .inputs.unconnected #chart-params {
            display: none;
        }

        #connection-params {
            margin-bottom: 0.5rem;
            display: grid;
            grid-template-columns: 69.77% 30%;
            column-gap: 0.25rem;
        }

        .inputs input {
            box-shadow: 1px 1px 0 var(--shadow-color);
            padding: 0.25rem;
        }

        #chart-params input {
            margin-right: 0.25rem;
        }

        #chart-params .param {
            width: 6%;
            font-family: monospace;
        }

        input {
            font-family: Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
            outline: none;
            border: none;
            font-size: 14pt;
            background-color: var(--input-background);
            color: var(--text-color);
        }

        .themes {
            float: right;
            font-size: 20pt;
            gap: 0.3rem;

            display: flex;
            justify-content: center;
        }

        #toggle-dark, #toggle-light {
            padding-right: 0.5rem;
            user-select: none;
            cursor: pointer;
        }

        #toggle-dark:hover, #toggle-light:hover {
            display: inline-block;
            transform: translate(1px, 1px);
            filter: brightness(125%);
        }

        #run {
            background: var(--button-background-color);
            color: var(--button-text-color);
            font-weight: bold;
            user-select: none;
            cursor: pointer;
            margin-bottom: 1rem;
        }

        #run:hover {
            filter: contrast(125%);
        }

        #add, #reload, #edit, #search {
            padding: 0.25rem 0.5rem;
            text-align: center;
            font-weight: bold;
            user-select: none;
            cursor: pointer;
            background: var(--new-chart-background-color);
            color: var(--new-chart-text-color);
            float: right;
            margin-right: 1rem !important;
            margin-left: 0rem;
            margin-bottom: 1rem;
            height: 3ex;
        }

        #add:hover, #reload:hover, #edit:hover, #search:hover {
            background: var(--button-background-color);
        }

        #search-query {
            float: right;
            width: 36%;
        }

        #global-error {
            align-self: center;
            width: 60%;
            padding: .5rem;

            color: var(--global-error-color);

            display: flex;
            flex-flow: row nowrap;
            justify-content: center;
        }

        form {
            display: inline;
        }

        form .param_name {
            font-size: 14pt;
            padding: 0.25rem;
            background: var(--param-background-color);
            color: var(--param-text-color);
            display: inline-block;
            box-shadow: 1px 1px 0 var(--shadow-color);
            margin-bottom: 0.5rem;
        }

        input:focus {
            box-shadow: 0 0 1rem var(--input-shadow-color);
        }

        .title {
            left: 50%;
            top: 0.25em;
            transform: translate(-50%, 0);
            font-size: 16pt;
            font-weight: bold;
            color: var(--title-color);
            z-index: 10;
        }

        .chart-buttons {
            cursor: pointer;
            display: none;
            position: absolute;
            top: 0.25rem;
            right: 0.25rem;
            font-size: 200%;
            color: #888;
            z-index: 10;
        }
        .chart-buttons a {
            margin-right: 0.25rem;
            user-select: none;
        }
        .chart-buttons a:hover {
            color: var(--chart-button-hover-color);
        }

        .disabled {
            opacity: 0.5;
        }

        .query-editor {
            display: none;
            grid-template-columns: auto fit-content(10%);
            grid-template-rows: auto fit-content(10%);
            z-index: 11;
            position: absolute;
            width: 100%;
            height: 100%;
        }

        .query-error {
            display: none;
            z-index: 10;
            position: absolute;
            color: var(--error-color);
            padding: 2rem;
        }

        textarea {
            padding: 0.5rem;
            outline: none;
            border: none;
            font-size: 12pt;
            background: var(--chart-background);
            color: var(--text-color);
            resize: none;
        }

        .query-editor textarea {
            grid-row: 1;
            grid-column: 1 / span 2;
            z-index: 11;
            border-bottom: 1px solid var(--edit-title-border);
            margin: 0;
        }

        .query-editor input {
            grid-row: 2;
            padding: 0.5rem;
        }

        .edit-title {
            background: var(--edit-title-background);
        }

        .edit-confirm {
            background: var(--button-background-color);
            color: var(--button-text-color);
            font-weight: bold;
            cursor: pointer;
        }
        .edit-confirm:hover {
            filter: contrast(125%);
        }

        .edit-cancel {
            cursor: pointer;
            background: var(--new-chart-background-color);
        }
        .edit-cancel:hover {
            filter: contrast(125%);
        }

        .nowrap {
            white-space: pre;
        }

        #mass-editor {
            display: none;
            grid-template-columns: auto fit-content(10%) fit-content(10%);
            grid-template-rows: auto fit-content(10%);
            row-gap: 1rem;
            column-gap: 1rem;
        }

        #mass-editor-textarea {
            width: 100%;
            height: 100%;
            grid-row: 1;
            grid-column: 1 / span 3;
        }

        #mass-editor input {
            padding: 0.5rem;
        }

        #mass-editor-message {
            color: var(--global-error-color);
        }

        #charts > div:only-child .display-only-if-more-than-one-chart {
            display: none;
        }

        .u-series {
            line-height: 0.8;
        }

        .u-series.footer {
            font-size: 8px;
            padding-top: 0;
            margin-top: 0;
        }

        /* Source: https://cdn.jsdelivr.net/npm/uplot@1.6.21/dist/uPlot.min.css
         * It is copy-pasted to lower the number of requests.
         */
        .uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;z-index: 100;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;z-index: 100;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
    </style>
</head>
<body>
<div class="inputs unconnected">
    <form id="params">
        <div id="connection-params">
            <input spellcheck="false" id="url" type="text" value="" placeholder="URL" />
            <div id="username-password">
                <input spellcheck="false" id="user" type="text" value="" placeholder="user" />
                <input spellcheck="false" id="password" type="password" placeholder="password" />
                <input id="hidden-submit" type="submit" hidden="true"/>
            </div>
        </div>
        <div id="button-options">
            <span class="nowrap themes"><span id="toggle-dark">🌚</span><span id="toggle-light">🌞</span></span>
            <input id="edit" type="button" value="✎" style="display: none;">
            <input id="add" type="button" value="Add chart" style="display: none;">
            <input id="reload" type="button" value="Reload">
            <span id="search-span" class="nowrap" style="display: none;"><input id="search" type="button" value="🔎" title="Run query to obtain list of charts from ClickHouse. Either select dashboard name or write your own query"><input id="search-query" name="search" list="search-options" type="text" spellcheck="false"><datalist id="search-options"></datalist></span>
            <div id="chart-params"></div>
        </div>
    </form>
    <div id="global-error"></div>
</div>
<div id="charts"></div>
<div id="mass-editor">
    <textarea id="mass-editor-textarea" spellcheck="false" data-gramm="false"></textarea>
    <span id="mass-editor-message">&nbsp;</span>
    <input type="submit" id="mass-editor-cancel" class="edit-cancel" value="Cancel">
    <input type="submit" id="mass-editor-confirm" class="edit-confirm" value="Apply">
</div>
<script>

/** Implementation note: it might be more natural to use some reactive framework.
  * But for now it is small enough to avoid it. As a bonus we have less number of dependencies,
  * which is better for maintainability.
  *
  * TODO:
  * - zoom on the graphs should work on touch devices;
  * - footer with "about" or a link to source code;
  * - allow to configure a table on a server to save the dashboards;
  * - if a query returned one value, display this value instead of a diagram;
  * - if a query returned something unusual, display the table;
  */

let host = location.protocol != 'file:' ? location.origin : 'http://localhost:8123/';
let user = 'default';
let password = '';
let add_http_cors_header = (location.protocol != 'file:');

const current_url = new URL(window.location);
/// Substitute user name if it's specified in the query string
const user_from_url = current_url.searchParams.get('user');
if (user_from_url) {
    user = user_from_url;
}


const errorCodeMessageMap = {
    516: 'Error authenticating with database. Please check your connection params and try again.'
}
const errorMessages = [
    {
        regex: /TypeError: Failed to fetch/,
        messageFunc: () => 'Error authenticating with database. Please check your connection url and try again.',
    },
    {
        regex: /Code: (\d+)/,
        messageFunc: (match) => {
            return errorCodeMessageMap[match[1]]
        }
    }
]

/// Dashboard selector
const dashboardSearchQuery = (dashboard_name) => `SELECT title, query FROM system.dashboards WHERE dashboard = '${dashboard_name}'`;
let dashboard_queries = {
    "Overview": dashboardSearchQuery("Overview"),
};
const default_dashboard = 'Overview';

/// Query to fill `queries` list for the dashboard
let search_query = dashboardSearchQuery(default_dashboard);
let customized = false;
let queries = [];

/// Query parameters with predefined default values.
/// All other parameters will be automatically found in the queries.
let default_params = {
    'rounding': '60',
    'seconds': '86400'
};
let params = default_params;

/// Palette generation for charts
function generateColor(l, c, h) {
    // oklch() does not work in firefox<=125 inside <canvas> element so we convert it back to rgb for now.
    // Based on https://github.com/color-js/color.js/blob/main/src/spaces/oklch.js
    const multiplyMatrices = (A, B) => {
        return [
            A[0]*B[0] + A[1]*B[1] + A[2]*B[2],
            A[3]*B[0] + A[4]*B[1] + A[5]*B[2],
            A[6]*B[0] + A[7]*B[1] + A[8]*B[2]
        ];
    }

    const oklch2oklab = ([l, c, h]) => [
        l,
        isNaN(h) ? 0 : c * Math.cos(h * Math.PI / 180),
        isNaN(h) ? 0 : c * Math.sin(h * Math.PI / 180)
    ]

    const srgbLinear2rgb = rgb => rgb.map(c =>
        Math.abs(c) > 0.0031308 ?
            (c < 0 ? -1 : 1) * (1.055 * (Math.abs(c) ** (1 / 2.4)) - 0.055) :
            12.92 * c
    )

    const oklab2xyz = lab => {
        const LMSg = multiplyMatrices([
            1,  0.3963377773761749,  0.2158037573099136,
            1, -0.1055613458156586, -0.0638541728258133,
            1, -0.0894841775298119, -1.2914855480194092,
        ], lab)
        const LMS = LMSg.map(val => val ** 3)
        return multiplyMatrices([
            1.2268798758459243,  -0.5578149944602171,  0.2813910456659647,
            -0.0405757452148008,  1.1122868032803170, -0.0717110580655164,
            -0.0763729366746601, -0.4214933324022432,  1.5869240198367816
        ], LMS)
    }

    const xyz2rgbLinear = xyz => {
        return multiplyMatrices([
            3.2409699419045226,  -1.537383177570094,   -0.4986107602930034,
           -0.9692436362808796,   1.8759675015077202,   0.04155505740717559,
            0.05563007969699366, -0.20397695888897652,  1.0569715142428786
        ], xyz)
    }

    const oklch2rgb = lch => srgbLinear2rgb(xyz2rgbLinear(oklab2xyz(oklch2oklab(lch))))

    let rgb = oklch2rgb([l, c, h]);
    return `rgb(${rgb[0] * 255}, ${rgb[1] * 255}, ${rgb[2] * 255})`;
}

function generatePalette(numColors) {

    palette = [];
    for (let i = 0; i < numColors; i++) {
        palette.push(generateColor(theme != 'dark' ? 0.75 : 0.5, 0.15, 360 * i / numColors));
    }
    return palette;
}

let theme = 'light';

function setTheme(new_theme) {
    theme = new_theme;
    document.documentElement.setAttribute('data-theme', theme);
    window.localStorage.setItem('theme', theme);
    drawAll();
}

document.getElementById('toggle-light').addEventListener('click', e => setTheme('light'));
document.getElementById('toggle-dark').addEventListener('click', e => setTheme('dark'));

/// uPlot objects will go here.
let plots = [];
/// chart div's will be here.
let charts = document.getElementById('charts');

/// This is not quite correct (we cannot really parse SQL with regexp) but tolerable.
const query_param_regexp = /\{(\w+):([^}]+)\}/g;

/// Automatically parse more parameters from the queries.
function findParamsInQuery(query, new_params) {
    const typeDefault = (type) => type.includes('Int') ? '0'
        : (type.includes('Float') ? '0.0'
        : (type.includes('Bool') ? 'false'
        : (type.includes('Date') ? new Date().toISOString().slice(0, 10)
        : (type.includes('UUID') ? '00000000-0000-0000-0000-000000000000'
        : ''))));
    for (let match of query.matchAll(query_param_regexp)) {
        const name = match[1];
        new_params[name] = params[name] || default_params[name] || typeDefault(match[2]);
    }
}

function findParamsInQueries() {
    let new_params = {};
    queries.forEach(q => findParamsInQuery(q.query, new_params));
    params = new_params;
}

function insertParam(name, value) {
    let param_wrapper = document.createElement('span');
    param_wrapper.className = 'nowrap';

    let param_name = document.createElement('span');
    param_name.className = 'param_name';
    let param_name_text = document.createTextNode(`${name}: `);
    param_name.appendChild(param_name_text);

    let param_value = document.createElement('input');
    param_value.className = 'param';
    param_value.name = `${name}`;
    param_value.type = 'text';
    param_value.value = value;
    param_value.spellcheck = false;

    let setWidth = e => { e.style.width = (e.value.length + 1) + 'ch' };
    if (value) { setWidth(param_value); }
    param_value.addEventListener('input', e => setWidth(e.target));

    param_wrapper.appendChild(param_name);
    param_wrapper.appendChild(param_value);
    document.getElementById('chart-params').appendChild(param_wrapper);
}

function buildParams() {
    let params_elem = document.getElementById('chart-params');
    while (params_elem.firstChild) {
        params_elem.removeChild(params_elem.lastChild);
    }

    for (let [name, value] of Object.entries(params)) {
        insertParam(name, value);
    }

    let run = document.createElement('input');
    run.id = 'run';
    run.type = 'submit';
    run.value = 'Ok';

    document.getElementById('chart-params').appendChild(run);
}

function updateParams() {
    [...document.getElementsByClassName('param')].forEach(e => { params[e.name] = e.value });
}

function getParamsForURL() {
    let url = '';
    for (let [name, value] of Object.entries(params)) {
        url += `&param_${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
    };
    return url;
}

function insertChart(i) {
    let q = queries[i];
    let chart = document.createElement('div');
    chart.className = 'chart';

    let chart_title = document.createElement('div');
    let title_text = document.createTextNode('');
    chart_title.appendChild(title_text);
    chart_title.className = 'title';
    chart.appendChild(chart_title);

    let query_error = document.createElement('div');
    query_error.className = 'query-error';
    query_error.appendChild(document.createTextNode(''));
    chart.appendChild(query_error);

    let query_editor = document.createElement('div');
    query_editor.className = 'query-editor';

    let query_editor_textarea = document.createElement('textarea');
    query_editor_textarea.spellcheck = false;
    query_editor_textarea.value = q.query;
    query_editor_textarea.placeholder = 'Query';
    query_editor_textarea.setAttribute('data-gramm', false);
    query_editor.appendChild(query_editor_textarea);

    let query_editor_title = document.createElement('input');
    query_editor_title.type = 'text';
    query_editor_title.value = q.title;
    query_editor_title.placeholder = 'Chart title';
    query_editor_title.className = 'edit-title';
    query_editor.appendChild(query_editor_title);

    let query_editor_confirm = document.createElement('input');
    query_editor_confirm.type = 'submit';
    query_editor_confirm.value = 'Ok';
    query_editor_confirm.className = 'edit-confirm';

    function getCurrentIndex() {
        /// Indices may change after deletion of other element, hence captured "i" may become incorrect.
        return [...charts.querySelectorAll('.chart')].findIndex(child => chart == child);
    }

    function editConfirm() {
        query_editor.style.display = 'none';
        query_error.style.display = 'none';
        q.title = query_editor_title.value;
        q.query = query_editor_textarea.value;
        title_text.data = '';
        findParamsInQuery(q.query, params);
        buildParams();
        refreshCustomized(true);
        saveState();
        const idx = getCurrentIndex();
        draw(idx, chart, getParamsForURL(), q.query);
    }

    query_editor_confirm.addEventListener('click', editConfirm);

    /// Ctrl+Enter (or Cmd+Enter on Mac) will also confirm editing.
    query_editor.addEventListener('keydown', event => {
        if ((event.metaKey || event.ctrlKey) && (event.keyCode == 13 || event.keyCode == 10)) {
            editConfirm();
        }
    });

    query_editor.addEventListener('keyup', e => {
        if (e.key == 'Escape') {
            query_editor.style.display = 'none';
        }
    });

    query_editor.appendChild(query_editor_confirm);

    chart.appendChild(query_editor);

    let edit_buttons = document.createElement('div');
    edit_buttons.className = 'chart-buttons';

    let move = document.createElement('a');
    let move_text = document.createTextNode('✥');
    move.appendChild(move_text);

    let drag_state = {
        is_dragging: false,
        idx: null,
        offset_x: null,
        offset_y: null,
        displace_idx: null,
        displace_chart: null
    };

    function dragStop(e) {
        drag_state.is_dragging = false;
        chart.className = 'chart';
        chart.style.left = null;
        chart.style.top = null;

        if (drag_state.displace_idx !== null) {
            const elem = queries[drag_state.idx];
            queries.splice(drag_state.idx, 1);
            queries.splice(drag_state.displace_idx, 0, elem);

            drag_state.displace_chart.className = 'chart';
            drawAll();
        }
    }

    function dragMove(e) {
        if (!drag_state.is_dragging) return;

        let x = e.clientX - drag_state.offset_x;
        let y = e.clientY - drag_state.offset_y;

        chart.style.left = `${x}px`;
        chart.style.top = `${y}px`;

        drag_state.displace_idx = null;
        drag_state.displace_chart = null;
        let current_idx = -1;
        for (const elem of charts.querySelectorAll('.chart')) {
            ++current_idx;
            if (current_idx == drag_state.idx) {
                continue;
            }

            const this_rect = chart.getBoundingClientRect();
            const this_center_x = this_rect.left + this_rect.width / 2;
            const this_center_y = this_rect.top + this_rect.height / 2;

            const elem_rect = elem.getBoundingClientRect();

            if (this_center_x >= elem_rect.left && this_center_x <= elem_rect.right
                && this_center_y >= elem_rect.top && this_center_y <= elem_rect.bottom) {

                elem.className = 'chart chart-displaced';
                drag_state.displace_idx = current_idx;
                drag_state.displace_chart = elem;
            } else {
                elem.className = 'chart';
            }
        }
    }

    function dragStart(e) {
        if (e.button !== 0) return; /// left button only
        move.setPointerCapture(e.pointerId);

        drag_state.is_dragging = true;
        drag_state.idx = getCurrentIndex();
        chart.className = 'chart chart-moving';

        drag_state.offset_x = e.clientX;
        drag_state.offset_y = e.clientY;
    }

    /// Read https://www.redblobgames.com/making-of/draggable/
    move.addEventListener('pointerdown', dragStart);
    move.addEventListener('pointermove', dragMove);
    move.addEventListener('pointerup', dragStop);
    move.addEventListener('pointerancel', dragStop);
    move.addEventListener('touchstart', (e) => e.preventDefault());

    let maximize = document.createElement('a');
    let maximize_text = document.createTextNode('🗖');
    maximize.appendChild(maximize_text);

    maximize.addEventListener('click', e => {
        const idx = getCurrentIndex();
        chart.className = (chart.className == 'chart' ? 'chart chart-maximized' : 'chart');
        resize();
    });

    let edit = document.createElement('a');
    let edit_text = document.createTextNode('✎');
    edit.appendChild(edit_text);

    function editStart() {
        query_editor.style.display = 'grid';
        query_editor_textarea.focus();
    }

    edit.addEventListener('click', e => editStart());
    if (!q.query) {
        editStart();
    }

    let trash = document.createElement('a');
    let trash_text = document.createTextNode('✕');
    trash.appendChild(trash_text);
    trash.addEventListener('click', e => {
        const idx = getCurrentIndex();
        if (plots[idx]) {
            plots[idx].destroy();
            plots[idx] = null;
        }
        plots.splice(idx, 1);
        charts.removeChild(chart);
        queries.splice(idx, 1);
        findParamsInQueries();
        buildParams();
        resize();
        refreshCustomized(true);
        saveState();
    });

    move.classList.add('display-only-if-more-than-one-chart');
    maximize.classList.add('display-only-if-more-than-one-chart');

    edit_buttons.appendChild(move);
    edit_buttons.appendChild(maximize);
    edit_buttons.appendChild(edit);
    edit_buttons.appendChild(trash);

    chart.appendChild(edit_buttons);

    chart.addEventListener('mouseenter', e => { edit_buttons.style.display = 'block'; });
    chart.addEventListener('mouseleave', e => { edit_buttons.style.display = 'none'; });

    charts.appendChild(chart);
    return {chart: chart, textarea: query_editor_textarea};
}

document.getElementById('add').addEventListener('click', e => {
    queries.push({ title: '', query: '' });

    const {chart, textarea} = insertChart(plots.length);
    chart.scrollIntoView();
    textarea.focus();

    plots.push(null);
    resize();
});

document.getElementById('reload').addEventListener('click', e => {
    reloadAll(queries.length == 0);
});

document.getElementById('search').addEventListener('click', e => {
    reloadAll(true);
});

let mass_editor_active = false;

function showMassEditor() {
    document.getElementById('charts').style.display = 'none';

    let editor_div = document.getElementById('mass-editor');
    editor_div.style.display = 'grid';

    let editor = document.getElementById('mass-editor-textarea');
    editor.value = JSON.stringify({params: params, queries: queries}, null, 2);

    editor.focus();
    mass_editor_active = true;
}

function hideMassEditor() {
    document.getElementById('mass-editor').style.display = 'none';
    document.getElementById('charts').style.display = 'flex';

    mass_editor_active = false;
}

function massEditorApplyChanges() {
    let editor = document.getElementById('mass-editor-textarea');
    ({params, queries} = JSON.parse(editor.value));
    hideMassEditor();
    regenerate();
    refreshCustomized(true);
    saveState();
    drawAll();
}

document.getElementById('edit').addEventListener('click', e => {
    if (mass_editor_active) {
        massEditorApplyChanges();
        hideMassEditor();
    } else {
        showMassEditor();
    }
});

document.getElementById('mass-editor-confirm').addEventListener('click', e => {
    massEditorApplyChanges();
    hideMassEditor();
});

document.getElementById('mass-editor-cancel').addEventListener('click', e => {
    hideMassEditor();
});

document.getElementById('mass-editor-textarea').addEventListener('input', e => {
    let message = document.getElementById('mass-editor-message').firstChild;
    message.data = '';
    if (e.target.value != '') {
        try { JSON.parse(e.target.value) } catch (e) {
            message.data = e.toString();
        }
    }
});


function legendAsTooltipPlugin({ className, style = { background: "var(--legend-background)" } } = {}) {
    let legendEl;
    let multiline;

    function init(u, opts) {
        legendEl = u.root.querySelector(".u-legend");
        legendEl.classList.remove("u-inline");
        className && legendEl.classList.add(className);

        uPlot.assign(legendEl.style, {
            textAlign: "right",
            pointerEvents: "none",
            display: "none",
            position: "absolute",
            left: 0,
            top: 0,
            zIndex: 100,
            boxShadow: "2px 2px 10px rgba(0, 0, 0, 0.1)",
            ...style
        });

        const nodes = legendEl.querySelectorAll("th");
        for (let i = 0; i < nodes.length; i++)
            nodes[i]._order = i;

        if (opts.series.length == 2) {
            multiline = false;
            for (let i = 0; i < nodes.length; i++)
                nodes[i].style.display = "none";
        } else {
            multiline = true;
            legendEl.querySelector("th").remove();
            legendEl.querySelector("td").setAttribute('colspan', '2');
            legendEl.querySelector("td").style.textAlign = 'center';
            let footer = legendEl.insertRow().insertCell();
            footer.setAttribute('colspan', '2');
            footer.style.textAlign = 'center';
            footer.classList.add('u-value');
            footer.parentNode.classList.add('u-series','footer');
            footer.textContent = ". . .";
        }

        const overEl = u.over;
        overEl.style.overflow = "visible";

        overEl.appendChild(legendEl);

        overEl.addEventListener("mouseenter", () => {legendEl.style.display = null;});
        overEl.addEventListener("mouseleave", () => {legendEl.style.display = "none";});
    }

    function nodeListToArray(nodeList) {
        return Array.prototype.slice.call(nodeList);
    }

    function update(u) {
        let { left, top } = u.cursor;
        /// This will make the balloon to the right of the cursor when the cursor is on the left side, and vise-versa,
        /// avoiding the borders of the chart.
        left -= legendEl.clientWidth * (left / u.width);
        if (top >= legendEl.clientHeight) {
            top -= legendEl.clientHeight;
        }
        legendEl.style.transform = "translate(" + left + "px, " + top + "px)";

        if (multiline) {
            let nodes = nodeListToArray(legendEl.querySelectorAll("tr"));
            let header = nodes.shift();
            let footer = nodes.pop();
            let showLimit = Math.floor(u.height / 30);
            nodes.forEach(function (node) { node._sort_key = nodes.length > showLimit ? +node.querySelector("td").textContent.replace(/,/g,'') : node._order; });
            nodes.sort((a, b) => b._sort_key - a._sort_key);
            nodes.forEach(function (node) { node.parentNode.appendChild(node); });
            for (let i = 0; i < nodes.length; i++) {
                nodes[i].style.display = i < showLimit ? null : "none";
            }
            footer.parentNode.appendChild(footer);
            footer.style.display = nodes.length > showLimit ? null : "none";
        }
    }

    return {
        hooks: {
            init: init,
            setCursor: update,
        }
    };
}


async function doFetch(query, url_params = '') {
    host = document.getElementById('url').value || host;
    user = document.getElementById('user').value;
    password = document.getElementById('password').value;

    let url = `${host}?default_format=JSONColumnsWithMetadata&enable_http_compression=1`

    if (add_http_cors_header) {
        // For debug purposes, you may set add_http_cors_header from a browser console
        url += '&add_http_cors_header=1';
    }

    if (user) {
        url += `&user=${encodeURIComponent(user)}`;
    }
    if (password) {
        url += `&password=${encodeURIComponent(password)}`;
    }

    let response, reply, error;
    try {
        response = await fetch(url + url_params, { method: "POST", body: query, headers: { 'Authorization': 'never' } });
        reply = await response.text();
        if (response.ok) {
            reply = JSON.parse(reply);
            if (reply.exception) {
                error = reply.exception;
            }
        } else {
            error = reply;
        }
    } catch (e) {
        console.log(e);
        error = e.toString();
    }

    if (error) {
        const errorMatch = errorMessages.find(({ regex }) => error.match(regex));
        if (!errorMatch) {
            throw new Error(error);
        }
        const match = error.match(errorMatch.regex);
        const message = errorMatch.messageFunc(match);
        if (message) {
            throw new Error(message);
        }
    }

    return {reply, error};
}

function stringHash(str) {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
        hash = ((hash << 5) - hash) + str.charCodeAt(i);
        hash = hash & hash;
    }
    return hash;
}

async function draw(idx, chart, url_params, query) {
    if (plots[idx]) {
        plots[idx].destroy();
        plots[idx] = null;
    }

    let {reply, error} = await doFetch(query, url_params);
    if (!error) {
        if (reply.rows == 0) {
            error = "Query returned empty result.";
        } else if (reply.meta.length < 2) {
            error = "Query should return at least two columns: unix timestamp and value.";
        } else {
            for (let i = 0; i < reply.meta.length; i++) {
                let label = reply.meta[i].name;
                let column = reply.data[label];
                if (!Array.isArray(column) || column.length != reply.data[reply.meta[0].name].length) {
                    error = "Wrong data format of the query.";
                    break;
                }
            }
        }
    }

    // Transform string-labeled data to multi-column data
    function transformToColumns() {
        const x = reply.meta[0].name; // time; must be ordered
        const l = reply.meta[1].name; // string label column to distinguish series; must be ordered
        const y = reply.meta[2].name; // values; must have single value for (x, l) pair
        const labels = [...new Set(reply.data[l])].sort((a, b) => a - b);
        if (labels.includes('__time__')) {
            error = "The second column is not allowed to contain '__time__' values.";
            return;
        }
        const times = [...new Set(reply.data[x])].sort((a, b) => a - b);
        let new_meta = [{ name: '__time__', type: reply.meta[0].type }];
        let new_data = { __time__: [] };
        for (let label of labels) {
            new_meta.push({ name: label, type: reply.meta[2].type });
            new_data[label] = [];
        }
        let new_rows = 0;
        function row_done(row_time) {
            new_rows++;
            new_data.__time__.push(row_time);
            for (let label of labels) {
                if (new_data[label].length < new_rows) {
                    new_data[label].push(null);
                }
            }
        }
        let prev_time = reply.data[x][0];
        const old_rows = reply.data[x].length;
        for (let i = 0; i < old_rows; i++) {
            const time = reply.data[x][i];
            const label = reply.data[l][i];
            const value = reply.data[y][i];
            if (prev_time != time) {
                row_done(prev_time);
                prev_time = time;
            }
            new_data[label].push(value);
        }
        row_done(prev_time);
        reply.meta = new_meta;
        reply.data = new_data;
        reply.rows = new_rows;
    }

    function isStringColumn(type) {
        return type === 'String' || type === 'LowCardinality(String)';
    }

    if (!error) {
        if (reply.meta.length == 3 && isStringColumn(reply.meta[1].type)) {
            transformToColumns();
        }
    }

    let error_div = chart.querySelector('.query-error');
    let title_div = chart.querySelector('.title');
    if (error) {
        error_div.firstChild.data = error;
        title_div.style.display = 'none';
        error_div.style.display = 'block';
        return false;
    } else {
        error_div.firstChild.data = '';
        error_div.style.display = 'none';
        title_div.style.display = 'block';
    }

    const color_rotate = Math.abs(stringHash(query));

    const line_color = theme != 'dark' ? generateColor(0.75, 0.14, (21 + color_rotate) % 360) : generateColor(0.53, 0.07, (56 + color_rotate) % 360);
    const fill_color = theme != 'dark' ? generateColor(0.96, 0.02, (21 + color_rotate) % 360) : generateColor(0.36, 0.07, (56 + color_rotate) % 360);
    const grid_color = theme != 'dark' ? "#eeeedd" : "#2c3235";
    const axes_color = theme != 'dark' ? "#2c3235" : "#c7d0d9";

    let sync = uPlot.sync("sync");

    function formatDateTime(t) {
        return (new Date(t * 1000)).toISOString().replace('T', '\n').replace('.000Z', '');
    }

    function formatDateTimes(self, ticks) {
        return ticks.map((t, idx) => {
            let res = formatDateTime(t);
            if (idx == 0 || res.substring(0, 10) != formatDateTime(ticks[idx - 1]).substring(0, 10)) {
                return res;
            } else {
                return res.substring(11);
            }
        });
    }

    function formatValue(v) {
        const a = Math.abs(v);
        if (a >= 1000000000000000) { return (v / 1000000000000000) + 'P'; }
        if (a >= 1000000000000) { return (v / 1000000000000) + 'T'; }
        if (a >= 1000000000) { return (v / 1000000000) + 'G'; }
        if (a >= 1000000) { return (v / 1000000) + 'M'; }
        if (a >= 1000) { return (v / 1000) + 'K'; }
        if (a > 0 && a < 0.001) { return (v * 1000000) + "μ"; }
        return v;
    }

    let axis_x = {
        stroke: axes_color,
        grid: { width: 1 / devicePixelRatio, stroke: grid_color },
        ticks: { width: 1 / devicePixelRatio, stroke: grid_color },
        values: formatDateTimes,
        space: 80,
        incrs: [1, 5, 10, 15, 30,
            60, 60 * 5, 60 * 10, 60 * 15, 60 * 30,
            3600, 3600 * 2, 3600 * 3, 3600 * 4, 3600 * 6, 3600 * 12,
            3600 * 24],
    };

    let axis_y = {
        stroke: axes_color,
        grid: { width: 1 / devicePixelRatio, stroke: grid_color },
        ticks: { width: 1 / devicePixelRatio, stroke: grid_color },
        values: (self, ticks) => ticks.map(formatValue)
    };

    let axes = [axis_x, axis_y];
    let series = [{ label: "time", value: (self, t) => formatDateTime(t) }];
    let data = [reply.data[reply.meta[0].name]];

    // Treat every column as series
    const series_count = reply.meta.length;
    const fill = series_count == 2 ? fill_color : undefined;
    const palette = series_count == 2 ? [line_color] : generatePalette(series_count);
    let max_value = Number.NEGATIVE_INFINITY;
    for (let i = 1; i < series_count; i++) {
        let label = reply.meta[i].name;
        series.push({ label, stroke: palette[i - 1], fill, points: { size: 3, fill: palette[i - 1] }});
        data.push(reply.data[label]);
        max_value = Math.max(max_value, ...reply.data[label]);
    }

    const opts = {
        width: chart.clientWidth,
        height: chart.clientHeight,
        scales: { x: { time: false } }, /// Because we want to split and format time on our own.
        axes,
        series,
        padding: [ null, null, null, 3 ],
        plugins: [ legendAsTooltipPlugin() ],
        cursor: {
            sync: {
                key: "sync",
            }
        }
    };

    plots[idx] = new uPlot(opts, data, chart);
    sync.sub(plots[idx]);

    /// Set title
    const title = queries[idx] && queries[idx].title ? queries[idx].title.replaceAll(/\{(\w+)\}/g, (_, name) => params[name] ) : '';
    chart.querySelector('.title').firstChild.data = title;
    return true;
}

function showError(message) {
    const charts = document.getElementById('charts');
    charts.style.height = '0px';
    charts.style.opacity = '0';
    document.getElementById('add').style.display = 'none';
    document.getElementById('edit').style.display = 'none';

    const error = document.getElementById('global-error');
    error.textContent = message;
    error.style.display = 'flex';
}

function hideError() {
    const charts = document.getElementById('charts');
    charts.style.height = 'auto';
    charts.style.opacity = '1';

    const error = document.getElementById('global-error');
    error.textContent = '';
    error.style.display = 'none';
}

let firstLoad = true;
let is_drawing = false; // Prevent race condition leading to duplicate/dangling charts.
async function drawAll() {
    if (is_drawing) return;
    is_drawing = true;

    let params = getParamsForURL();
    const chartsArray = document.getElementsByClassName('chart');

    if (!firstLoad) {
        hideError();
    }
    await Promise.all([...Array(queries.length)].map(async (_, i) => {
        return draw(i, chartsArray[i], params, queries[i].query).catch((e) => {
            if (!firstLoad) {
                showError(e.message);
            }
            return false;
        });
    })).then((results) => {
        if (firstLoad) {
            firstLoad = false;
        } else {
            enableButtons();
        }
        if (results.includes(true)) {
            const element = document.querySelector('.inputs');
            element.classList.remove('unconnected');
            document.getElementById('add').style.display = 'inline-block';
            document.getElementById('edit').style.display = 'inline-block';
            document.getElementById('search-span').style.display = '';
            hideError();
        } else {
            document.getElementById('charts').style.height = '0px';
        }
    });

    is_drawing = false;
}

function resize() {
    plots.forEach(plot => {
        if (plot) {
            let chart = plot.over.closest('.chart');
            plot.setSize({ width: chart.clientWidth, height: chart.clientHeight });
        }
    });
}

new ResizeObserver(resize).observe(document.body);

function disableButtons() {
    const reloadButton = document.getElementById('reload');
    reloadButton.value = 'Reloading…';
    reloadButton.disabled = true;
    reloadButton.classList.add('disabled');

    const runButton = document.getElementById('run');
    if (runButton) {
        runButton.value = 'Reloading…';
        runButton.disabled = true;
        runButton.classList.add('disabled');
    }

    const searchButton = document.getElementById('search');
    searchButton.value = '…';
    searchButton.disabled = true;
    searchButton.classList.add('disabled');
}

function enableButtons() {
    const reloadButton = document.getElementById('reload');
    reloadButton.value = 'Reload';
    reloadButton.disabled = false;
    reloadButton.classList.remove('disabled');

    const runButton = document.getElementById('run');
    if (runButton) {
        runButton.value = 'Ok';
        runButton.disabled = false;
        runButton.classList.remove('disabled');
    }

    const searchButton = document.getElementById('search');
    searchButton.value = '🔎';
    searchButton.disabled = false;
    searchButton.classList.remove('disabled');
}

async function reloadAll(do_search) {
    disableButtons();
    try {
        updateParams();
        if (do_search) {
            search_query = toSearchQuery(document.getElementById('search-query').value);
            queries = [];
            refreshCustomized(false);
        }
        saveState();
        if (do_search) {
            await searchQueries();
        }
        await drawAll();
    } catch (e) {
        showError(e.message);
    }
    enableButtons();
}

document.getElementById('params').onsubmit = function(event) {
    if (document.activeElement === document.getElementById('search-query')) {
        reloadAll(true);
    } else {
        reloadAll(queries.length == 0);
    }
    event.preventDefault();
}

const decodeState = (x) => JSON.parse(LZString.decompressFromEncodedURIComponent(x) || atob(x));
const encodeState = (x) => LZString.compressToEncodedURIComponent(JSON.stringify(x));

function saveState() {
    const state = { host, user, queries, params, search_query, customized };
    history.pushState(state, '',
        window.location.pathname + (window.location.search || '') + '#' + encodeState(state));
}

async function searchQueries() {
    let {reply, error} = await doFetch(search_query);
    if (error) {
        throw new Error(error);
    }
    let data = reply.data;
    if (reply.rows == 0) {
        throw new Error("Search query returned empty result.");
    } else if (reply.meta.length != 2 || reply.meta[0].name != "title" || reply.meta[1].name != "query") {
        throw new Error("Search query should return exactly two columns: title and query.");
    } else if (!Array.isArray(data.title) || !Array.isArray(data.query) || data.title.length != data.query.length) {
        throw new Error("Wrong data format of the search query.");
    }

    for (let i = 0; i < data.title.length; i++) {
        queries.push({title: data.title[i], query: data.query[i]});
    }

    regenerate();
}

function refreshCustomized(value) {
    if (value !== undefined) {
        customized = value;
    }
    document.getElementById('search-span').style.opacity = customized ? 0.5 : 1.0;
}

function updateFromState() {
    document.getElementById('url').value = host;
    document.getElementById('user').value = user;
    document.getElementById('password').value = password;
    document.getElementById('search-query').value = fromSearchQuery(search_query);
    refreshCustomized();
}

function regenerate() {
    findParamsInQueries();
    buildParams();

    plots.forEach(elem => elem && elem.destroy());
    plots = queries.map(e => null);

    while (charts.firstChild) {
        charts.removeChild(charts.lastChild);
    }

    for (let i = 0; i < queries.length; ++i) {
        insertChart(i);
    }
}

window.onpopstate = function(event) {
    if (!event.state) { return; }
    ({host, user, queries, params, search_query, customized} = event.state);
    updateFromState();
    regenerate();
    drawAll();
};

if (window.location.hash) {
    try {
        let search_query_, customized_;
        ({host, user, queries, params, search_query_, customized_} = decodeState(window.location.hash.substring(1)));

        // For compatibility with old URLs' hashes
        search_query = search_query_ !== undefined ? search_query_ : search_query;
        customized = customized_ !== undefined ? customized_ : true;
    } catch {}
}

function fromSearchQuery(query) {
    for (const dashboard_name in dashboard_queries) {
        if (query == dashboard_queries[dashboard_name])
            return dashboard_name;
    }
    return query;
}

function toSearchQuery(value) {
    if (value in dashboard_queries)
        return dashboard_queries[value];
    else
        return value;
}

async function populateSearchOptions() {
    let {reply, error} = await doFetch("SELECT dashboard FROM system.dashboards GROUP BY dashboard ORDER BY ALL");
    if (error) {
        throw new Error(error);
    }
    let data = reply.data;
    if (data.dashboard.length == 0) {
        console.log("Unable to fetch dashboards list");
        return;
    }
    dashboard_queries = {};
    for (let i = 0; i < data.dashboard.length; i++) {
        const dashboard = data.dashboard[i];
        dashboard_queries[dashboard] = dashboardSearchQuery(dashboard);
    }
    const searchOptions = document.getElementById('search-options');
    for (const dashboard in dashboard_queries) {
        const opt = document.createElement('option');
        opt.value = dashboard;
        searchOptions.appendChild(opt);
    }
}

async function start() {
    try {
        updateFromState();
        if (queries.length == 0) {
            await searchQueries();
        } else {
            regenerate();
        }
        saveState();
        let new_theme = window.localStorage.getItem('theme');
        if (new_theme && new_theme != theme) {
            setTheme(new_theme);
        } else {
            drawAll();
        }
        await populateSearchOptions();
    } catch (e) {
        showError(e.message);
    }
}

start();

</script>
</body>
</html>
